iT邦幫忙

2026 iThome 鐵人賽

DAY 1
0
Software Development

打造 OS Kernel:從 OS in 1,000 Lines 到 xv6系列 第 1

Day 01|作業系統到底在做什麼?建立我們的 Tiny Kernel 專案

  • 分享至 

  • xImage
  •  

如果這次實驗成功,畫面會停住,而且不會印出 Hello World

這聽起來不像值得慶祝的結果,但原因很簡單:我們還沒寫出讓核心顯示文字的功能。
螢幕上的開機資訊來自 OpenSBI 韌體,不是我們的程式,因此不能看見幾行文字就認定核心已經啟動。
真正要確認的是,CPU 有沒有執行到我們安排的位置。

這個系列會跟著 Seiya Nuta 的《OS in 1,000 Lines》(繁體中文版《1000 行打造作業系統》)動手做,再用《Operating System Concepts》補上需要的理論。
第一篇採用〈核心啟動〉的最小範例,工作只有準備堆疊、清零 .bss,最後留在迴圈中,不急著把完整作業系統塞進第一天。

QEMU 負責模擬硬體,OpenSBI 負責早期初始化,我們的核心則從 boot() 接手。
接下來會沿著這條路徑檢查:

OpenSBI 韌體
  ↓ 切換到監督者模式(S-mode)
boot():核心進入點
  ↓ 設定堆疊指標 sp
kernel_main()
  ↓ 清除 .bss
for (;;):停留在無限迴圈

pc 是程式計數器(Program Counter),用來觀察 CPU 目前的指令位址。
sp 是堆疊指標(Stack Pointer),__stack_top 則是稍後由連結器腳本定義的堆疊頂端位址。
先不用背下這幾個名字,等編譯結果出來後,我們會把它們一個個對回程式碼。
最後要回答的問題是:

pc 是否對應 kernel_main() 裡的無限迴圈?
sp 是否與這次建置的 __stack_top 位址一致?

只有一個無限迴圈,也能叫作業系統嗎?

嚴格來說,現在還沒有一般使用者期待的那些作業系統功能。
你不能在裡面開檔案,也不能執行另一個程式,稱它為「核心的起點」比較合適。
不過,先把它啟動起來,才能逐步接上管理資源的工作。

俗稱「恐龍書」的《Operating System Concepts》第 10 版,在第 1.1 節把電腦系統拆成四個部分:使用者、應用程式、作業系統與硬體。
CPU、記憶體與輸入/輸出(I/O)裝置提供資源,應用程式利用資源完成工作,作業系統則控制硬體並協調不同程式如何使用它。

例如瀏覽器和編輯器同時執行,它們都需要 CPU,也都需要記憶體。
誰先跑、哪一塊記憶體可以分出去,不能只靠應用程式各自決定,這就是書中**資源配置者(Resource Allocator)**的角色。

另外,如果某個程式出了錯,能不能任意改掉別人的資料,甚至關閉整個系統的中斷?
作業系統還需要約束程式與 I/O 的執行,這對應到**控制程式(Control Program)**的角色。
前者處理如何分配,後者處理如何維持可管理的執行環境,實際功能經常同時涉及兩者。

本系列後續的實作會逐步完成這些責任,例如:

分頁配置器(Page Allocator)/排程器(Scheduler)
→ Resource Allocator

陷阱處理(Trap)/權限模式(Privilege Mode)/系統呼叫(System Call)
→ Control Program

書中也提醒,Operating System 沒有一個適用所有電腦的完整定義。
常見的實用說法,是把持續執行並管理系統的核心程式稱為 Kernel。

今天完成的 kernel.elf 是存放核心程式的 ELF(Executable and Linkable Format)執行檔。
這個核心還不會動態配置記憶體、排程行程(Process)或處理系統呼叫,後續會逐步補上這些功能。

用硬體權限區分應用程式與核心

《Operating System Concepts》第 1.4.2 節用使用者模式(User Mode)與核心模式(Kernel Mode)說明硬體保護邊界。
應用程式需要核心服務時,會發出系統呼叫,經由受控的陷阱處理流程進入核心。
硬體中斷(Interrupt)也可能讓核心取得控制權,例如處理裝置事件。

本次 RISC-V 實驗中,各權限模式的分工如下:

M-mode   機器模式(Machine Mode)        OpenSBI 韌體
S-mode   監督者模式(Supervisor Mode)   我們的核心
U-mode   使用者模式(User Mode)        後續加入的應用程式

今天會看到 OpenSBI 在 M-mode 完成初始化,再把 CPU 交給 S-mode 的 kernel.elf
U-mode 要等到 Day 16~Day 17 才會出現。

先把這段理論記成一個具體方向就夠了:後續每加入一項核心功能,都要知道它在管理什麼資源、限制什麼操作。
這次還沒開始分配 CPU 時間,先確認 CPU 願意照著我們的程式跑。

實驗環境:沿用 OS in 1,000 Lines 的 RV32

本系列前 18 天沿用教材的 32 位元 RISC-V(RV32)環境,並使用 QEMU 的 virt 虛擬平台。

Day 19 起會進入 MIT 的教學作業系統 xv6-riscv,它使用 64 位元 RISC-V(RV64)。
屆時會比較位址寬度、應用程式二進位介面(ABI)與分頁表(Page Table)的差異。

以下安裝步驟以 Ubuntu 24.04.4 LTS 為例。
先安裝 Clang、LLVM、LLD、QEMU 與 Make:

sudo apt update
sudo apt install -y clang llvm lld qemu-system-misc curl make

《OS in 1,000 Lines》的〈開始〉一章使用 qemu-system-riscv32 作為套件名。
在本文的 Ubuntu 環境中,該執行檔由 qemu-system-misc 套件提供,安裝後使用的命令仍然是 qemu-system-riscv32

確認 Clang 支援 RV32:

clang -print-targets | grep riscv32

預期看到:

riscv32 - 32-bit RISC-V

再記錄各工具版本:

clang --version | head -n 1
ld.lld --version | head -n 1
llvm-readelf --version | head -n 1
llvm-objdump --version | head -n 1
qemu-system-riscv32 --version | head -n 1

這幾個工具不是同一件事的不同名字。
Clang 把 C 或組語來源交給編譯流程,LLD 負責連結,llvm-readelfllvm-objdump 用來檢查結果,QEMU 才負責執行目標機器的程式。
即使你的電腦是 x86-64,也能產生 RV32 程式,這叫交叉編譯,不必先找一台 RISC-V 電腦。

版本輸出是為了日後對照,不要求每個人都與範例完全相同。
但若某一行出現 command not found,要先處理工具安裝,繼續修改核心程式不會解決這個問題。

第一個 Kernel 專案

在你準備存放系列檔案的工作目錄執行:

mkdir -p 30-days-os-kernel/tiny-kernel
cd 30-days-os-kernel/tiny-kernel

如果已有這個資料夾,直接進入即可。
若你的編輯器已開啟 30-days-os-kernel/,只需要執行 mkdir -p tiny-kernelcd tiny-kernel,不要再建立一層同名的系列資料夾。
接下來的建檔、編譯與 QEMU 指令都在 tiny-kernel/ 執行,完成後目錄會包含:

tiny-kernel/
├── .gitignore
├── Makefile
├── kernel.c
└── kernel.ld

kernel.c 存放核心程式,其中的 boot() 負責準備堆疊,再進入 kernel_main()
這個進入點採用教材中的 naked 函式與內嵌組合語言(Inline Assembly),稍後會說明它們的用途。

先替程式找位置:kernel.ld

連結器腳本(Linker Script)用來指定程式各區段的配置方式。
建立 kernel.ld

OUTPUT_ARCH(riscv)
ENTRY(boot)

SECTIONS {
    . = 0x80200000;

    .text : {
        KEEP(*(.text.boot));
        *(.text .text.*);
    }

    .rodata : ALIGN(4) {
        *(.rodata .rodata.*);
    }

    .data : ALIGN(4) {
        *(.data .data.*);
    }

    .bss : ALIGN(4) {
        __bss = .;
        *(.bss .bss.* .sbss .sbss.*);
        __bss_end = .;
    }

    . = ALIGN(4);
    . += 128 * 1024;
    __stack_top = .;
}

這份檔案先決定四件事:

ENTRY(boot)        ELF 進入點是 boot()
0x80200000         Kernel 起始位址
__bss~__bss_end   尚未初始化資料的範圍
__stack_top        128 KB Kernel Stack 的頂端

在本文的啟動方式中,QEMU 透過 -kernel 載入核心映像,OpenSBI 完成初始化後再把控制權交給核心。
我們將核心起始位址設為 0x80200000
因此 .text.boot 必須排在 .text 最前方,讓 boot() 的第一條指令落在這個位址。

RISC-V 的堆疊往較低位址成長,所以初始 sp 要指向預留區域的高位址端,也就是 __stack_top

這裡的點號不是省略號

SECTIONS 裡的 . 是連結器目前安排到的位址。
. = 0x80200000 設定起點,放入各區段後,位置會跟著往後移動,最後 . += 128 * 1024 再替堆疊預留範圍。
這不代表 CPU 已配置出一塊記憶體,更不是向 Ubuntu 呼叫 malloc,只是安排核心預計使用的位址。

幾個區段可以從資料用途理解:機器指令放在 .text,唯讀常數放在 .rodata,有初始內容的可寫資料放在 .data,零初始化資料則由 .bss 描述。
.bss 不需要在檔案中逐 byte 存下所有零,但執行前仍必須確保對應 RAM 的值正確,稍後的清零程式就在做這件事。

堆疊前的 ALIGN(4) 沿用教材的最小配置,不代表一般 RISC-V 函式呼叫只需要四-byte 對齊。
Day 02 會把它改為 ILP32 ABI 要求的 16-byte 對齊,再加入獨立的組語函式實驗。

沒有作業系統幫忙啟動,C 程式從哪裡開始?

平常寫 int main(),堆疊和執行環境通常已由系統與啟動程式準備好。
這次沒有那一層支援,不能光把函式命名為 main 就期待 CPU 自動找過來,必須明確安排進入點。

建立 kernel.c

typedef unsigned char uint8_t;
typedef unsigned int uint32_t;
typedef uint32_t size_t;

extern char __bss[], __bss_end[], __stack_top[];

void *memset(void *buf, char c, size_t n) {
    uint8_t *p = (uint8_t *) buf;
    while (n--)
        *p++ = c;
    return buf;
}

void kernel_main(void) {
    memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);

    for (;;)
        ;
}

__attribute__((section(".text.boot")))
__attribute__((naked))
void boot(void) {
    __asm__ __volatile__(
        "mv sp, %[stack_top]\n"
        "j kernel_main\n"
        :
        : [stack_top] "r" (__stack_top)
    );
}

這段程式的執行順序不是從 kernel_main() 開始,而是:

boot()
  ↓ mv sp, __stack_top
設定 Kernel Stack
  ↓ j kernel_main
清除 .bss
  ↓
無限迴圈

section(".text.boot")boot() 放到 Linker Script 保留的最前方。
naked 則要求編譯器不要自動加入函式開頭與結尾的堆疊操作,因為此時還沒有設定我們自己的核心堆疊。

extern char __bss[] 等宣告不是要讀取一個字元,而是要取得 Linker Script 建立的符號位址。
kernel_main() 用這兩個位址算出 .bss 長度,再把內容清成零。

memset() 也由我們自己提供,因為現在沒有連結一般應用程式使用的標準函式庫。
這是教材的精簡版本,不是在實作完整 libc,參數型別與使用範圍都先配合這個小核心。

最後使用 j kernel_main 而不是需要返回的普通呼叫,因為核心初始化後沒有一個上層應用程式可以回去。
kernel_main() 的無限迴圈讓執行位置保持可觀察,但它仍在消耗模擬 CPU 的執行時間,不等於硬體已睡眠。

編譯命令很長,先看懂它在做什麼

這裡與官方教材有一個操作上的差別:教材使用 run.sh,本文專案使用 Makefile。
Make 不會替核心提供任何執行能力,只是把下面的 Clang 與 QEMU 命令取好名稱,讓我們可以分開編譯、檢查與啟動。
對照教材時,請比較實際執行的命令與參數,不用把 Makefile 當成另一套 Kernel 技術。

建立 Makefile:

CC := clang
READELF := llvm-readelf
OBJDUMP := llvm-objdump
NM := llvm-nm
QEMU := qemu-system-riscv32

BUILD_DIR := build
KERNEL := $(BUILD_DIR)/kernel.elf
MAP := $(BUILD_DIR)/kernel.map
FIRMWARE := opensbi-riscv32-generic-fw_dynamic.bin
FIRMWARE_URL := https://github.com/qemu/qemu/raw/v8.0.4/pc-bios/opensbi-riscv32-generic-fw_dynamic.bin

CFLAGS := \
	-std=c11 \
	-O2 \
	-g3 \
	-Wall \
	-Wextra \
	--target=riscv32-unknown-elf \
	-march=rv32imac \
	-mabi=ilp32 \
	-fuse-ld=lld \
	-fno-stack-protector \
	-ffreestanding \
	-nostdlib \
	-Wl,-T,kernel.ld \
	-Wl,-Map,$(MAP)

.PHONY: all check disasm symbols firmware run clean

all: $(KERNEL)

$(BUILD_DIR):
	mkdir -p $(BUILD_DIR)

$(KERNEL): kernel.c kernel.ld | $(BUILD_DIR)
	$(CC) $(CFLAGS) -o $@ kernel.c

check: $(KERNEL)
	file $(KERNEL)
	$(READELF) -h $(KERNEL)

disasm: $(KERNEL)
	$(OBJDUMP) -d $(KERNEL)

symbols: $(KERNEL)
	$(NM) -n $(KERNEL)

firmware: $(FIRMWARE)

$(FIRMWARE):
	curl -L --fail --output $@ $(FIRMWARE_URL)

run: $(KERNEL) $(FIRMWARE)
	$(QEMU) \
		-machine virt \
		-bios $(FIRMWARE) \
		-nographic \
		-serial mon:stdio \
		--no-reboot \
		-kernel $(KERNEL)

clean:
	rm -rf $(BUILD_DIR)

幾個編譯選項值得先認識:

  • --target=riscv32-unknown-elf 指定 RV32 bare-metal 目標
  • -ffreestanding 告訴編譯器,程式在自行提供啟動流程與基本支援的環境中執行
  • -nostdlib 不自動連結標準函式庫與啟動檔
  • -fno-stack-protector 避免編譯器插入目前尚未提供的 Stack Protection 支援
  • -Wl,-T,kernel.ld 指定 Linker Script
  • -Wl,-Map,build/kernel.map 保存 Linker 配置結果

暫時不用記住整份 Makefile

先知道這四種操作就足夠往下走:

  • make:編譯 kernel.c 並產生 build/kernel.elf
  • make check:查看 ELF 格式與進入點
  • make symbolsmake disasm:查看符號位址與機器碼對應的組合語言
  • make run:使用下載的 OpenSBI 韌體啟動核心

核心程式仍沿用該章的流程:boot() 設定堆疊、跳入 kernel_main(),再清除 .bss
Makefile 另以 -march=rv32imac-mabi=ilp32 明確指定指令集及 ABI,並用 -bios 指定本機韌體檔案。

-g3 保留除錯資訊,讓工具能把位址對回函式與來源位置,並不代表 ELF 只能拿來除錯。
-O2 則允許最佳化,因此 C 的一行不一定對應組語的一行,後面會直接看反組譯,而不是猜編譯器應該產生什麼。

另外注意:Makefile 中真正執行命令的行首要使用 Tab,不是幾個空白。
若出現 missing separator,先檢查縮排,不必回頭修改 kernel.c

編譯成功之後,先別急著啟動

在前面已進入的 tiny-kernel/ 目錄執行:

make clean
make
make check

make clean 只應用於這份專案的 build/ 產物,若你改過 Makefile,先確認清理目標沒有指到其他資料夾。
make check 的結果應包含以下類型資訊,排版可能因工具版本不同而略有差異:

build/kernel.elf: ELF 32-bit LSB executable, UCB RISC-V, RVC,
soft-float ABI, version 1 (SYSV), statically linked,
with debug_info, not stripped

Class:                             ELF32
Machine:                           RISC-V
Entry point address:               0x80200000

接著查看符號:

make symbols

以下是一份符號表範例:

80200000 T boot
8020000e T memset
80200024 T kernel_main
8020004c B __bss
8020004c B __bss_end
8022004c B __stack_top

T 表示符號位於程式碼區段,B 表示位於 .bss 區段。
除了在腳本中指定的起始位址,其餘符號位址可能隨編譯器版本、選項或程式修改而改變,請以自己這次建置的輸出為準。

boot 確實落在 0x80200000
__stack_top.bss 起點相差 0x20000,換算後就是 Linker Script 保留的 128 KB。

目前沒有未初始化的全域變數,所以 __bss__bss_end 位址相同。
清除長度為零不會造成問題,未來加入 .bss 資料時,這段初始化流程仍然適用。

再看反組譯:

make disasm

對應上述範例位址,關鍵部分如下:

80200000 <boot>:
80200000: 37 05 22 80   lui   a0, 0x80220
80200004: 13 05 c5 04   addi  a0, a0, 0x4c
80200008: 2a 81         mv    sp, a0
8020000a: 6f 00 a0 01   j     0x80200024 <kernel_main>

80200024 <kernel_main>:
...
80200048: 01 a0         j     0x80200048 <kernel_main+0x24>

這正是原始碼描述的流程:boot() 先設定 sp,再跳進 kernel_main(),最後停在無限迴圈。

ELF header 回答進入點在哪裡,符號表回答函式與變數在哪裡,反組譯則回答那個位置到底放了什麼指令。
三者用途不同,file 說它是 RISC-V 執行檔,還不足以證明它已經在 QEMU 執行。

畫面停住了,現在才開始驗證

先下載 OS in 1,000 Lines 使用的 RV32 OpenSBI firmware:

make firmware
sha256sum opensbi-riscv32-generic-fw_dynamic.bin

這份固定韌體檔案的既有紀錄如下,請把自己的輸出與它比較:

3de92c4ac5acb09a4e8678ce730491d9ff017c17669c67f4a9188f480c6fad42

啟動 Kernel:

make run

OpenSBI 資訊裡先找:

Domain0 Next Address      : 0x80200000
Domain0 Next Mode         : S-mode

這表示 OpenSBI 會從 M-mode 將 CPU 交給位於 0x80200000 的 S-mode Kernel。

接著按 Ctrl+A,放開後再按 C,進入 QEMU monitor:

(qemu) info registers

CPU#0
pc       80200048
...
x2/sp    8022004c

把結果與剛才的符號表放在一起:

pc                0x80200048
kernel_main loop  0x80200048

sp                0x8022004c
__stack_top       0x8022004c

以這組範例來說,兩組位址都吻合,才可以判斷 CPU 已經走過 boot(),目前停留在我們預期的迴圈。
你的位址可能不同,重要的是「自己的 ELF」與「自己的 QEMU 執行結果」對得上,不是數字一定要與本文相同。

此處 sp 相等的判準針對這份反組譯所示的最小程式。
如果你的編譯器在 kernel_main() 中建立了 stack frame,sp 可能比 __stack_top 低,應先檢查函式開頭調整了多少空間,不要一看到不相等就認定啟動失敗。

在 monitor 輸入 q 會結束整個 QEMU 程序,返回主機的終端機。
也可以切回 Console 後按 Ctrl+A,放開再按 X 結束模擬器。

用暫存器結果判斷核心是否啟動

目前的 Kernel 還沒有任何輸出函式,單靠畫面靜止無法判斷啟動是否成功。
確認 pc 落在本次反組譯結果中的迴圈指令,且 sp 與本次建置的堆疊頂端一致,才是這個範例的驗證方式。

你可能會想,直接加一行 printf 不是比較快嗎?
但 printf 也需要實作與輸出通道,現在還沒把它們接進來,所以暫存器與反組譯先成為我們的觀察工具。
Day 05 補上 Console 後,才能換成在核心內印出除錯資訊。

卡住時先檢查四件事

Clang 找不到 RV32 target

clang -print-targets | grep riscv

沒有 riscv32 時,先確認 which clang 與套件來源。
這是工具鏈問題,不是 kernel.c 寫錯。

QEMU 找不到 firmware

ls -lh opensbi-riscv32-generic-fw_dynamic.bin

檔案不存在時重新執行 make firmware

boot 不在 0x80200000

make symbols
llvm-readelf -h build/kernel.elf

確認 ENTRY(boot).text.bootKEEP(*(.text.boot)) 都存在。

sp 不等於 __stack_top

先比較以下兩項:

make symbols | grep __stack_top
(qemu) info registers

若位址不同,先檢查 kernel_main() 是否調整了 sp,再回頭檢查 boot() 的 Inline Assembly 與 naked attribute。
也要確認沒有拿舊的符號表對照新編譯的 ELF。

保存 Day 01

結束 QEMU 後,從 tiny-kernel/ 回到系列資料夾:

cd ..

若這個資料夾尚未納入 Git 儲存庫,再執行:

git init

tiny-kernel/.gitignore 填入以下內容,排除可重新產生的編譯結果與下載的韌體:

/build/
/opensbi-riscv32-generic-fw_dynamic.bin

從系列資料夾保存實作檔案:

git add tiny-kernel/.gitignore \
        tiny-kernel/Makefile \
        tiny-kernel/kernel.c \
        tiny-kernel/kernel.ld

git commit -m "day01: bootstrap riscv kernel project"

今天新增的功能很少,但已建立一條可以重現的證據鏈:

原始碼
→ ELF
→ Symbol/Disassembly
→ OpenSBI
→ QEMU Register

這條鏈會成為後面所有 Kernel 除錯的起點。

下一個實驗

Day 02 會回頭拆解今天反組譯中出現的 RISC-V 指令。
我們將操作 a0spra,比較 C、Assembly 與 Machine Code 如何描述同一個動作。

參考資料


系列文
打造 OS Kernel:從 OS in 1,000 Lines 到 xv61
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言